10.3.1 概述
Android 屏幕刷新的时间间隔是 16ms,如果 View 能够在 16ms 内完成所需执行的绘图操作,那么在视觉上,界面就是流畅的;否则就会出现卡顿。很多时候,在自定义 View 的日志中,经常会看到如下警告:
之所以会出现这些警告,大部分是因为我们在绘制过程中不单单执行了绘图操作,也夹杂了很多逻辑处理,导致在指定的 16ms 内并没有完成绘制,出现界面卡顿和警告。为了解决这个问题,Android 引入了 SurfaceView。它在两个方面改进了 View 的绘图操作:
- 使用双缓冲技术。
- 自带画布,支持在子线程中更新画布内容。
所谓双缓冲技术,简单来讲,就是多加一块缓冲画布,当需要执行绘图操作时,现在缓冲画布上绘制,绘制好后直接将缓冲画布上的内容更新到主画布上。这样,在屏幕更新时,只需把换缓冲画布上的内容照样画过来就可以了,就不会存在逻辑处理时间的问题,也就解决了超时绘制的问题。具体详见 10.3.3。
虽然 SurfaceView 在处理耗时操作时很有用,但正是因为在新的线程中更新画面,所以不会阻塞主线程。但这也带来了另一个问题,就是事件同步。比如,你触摸了屏幕,SurfaceView 就会调用线程来处理,当线程过多时,一般就需要一个线程队列来保存触摸事件,这会稍稍复杂一点,因为涉及线程同步。
总之,View 和 SurfaceView 都有各自的应用场景:
- 当界面需要被动更新时,用 View 较好。比如,与手势交互的场景,因为画面的更新是依赖 onTouch 来完成的,所以可以直接使用 invalidate() 函数。在这种情况下,这一次 Touch 和下一次 Touch 间隔的时间比较长,不会产生影响。
- 当界面需要主动更新,用 SurfaceView 较好。比如一个人在一直跑动,这就需要一个单独的线程不停地重绘人的状态,避免阻塞主线程。显然 View 不合适,需要 SurfaceView 来控制。
- 当界面绘制需要频繁刷新,或者刷新是数据处理量比较大时,就应该用 SurfaceView 来实现,比如视频播放及 Camera。
10.3.2 SurfaceView 的基本用法
1. 实现 View 功能
SurfaceView 派生自 View,所以 SurfaceView 能使用 View 中的所有方法,但要注意,View 中的所有方法都是在主线程中执行。下面使用 SurfaceView 来实现捕捉用户手势轨迹的自定义控件。
然而,效果却是不显示手势轨迹,而一直显示黑屏。查看日志,如下图所示。
从日志中可以看出,上述代码只调用了 postInavidate() 函数,而没有调用 onDraw() 函数。这是为什么呢?当你把 init() 函数中注释掉的一行代码打开以后,就会发现可以看到手势轨迹了。
setWillNotDraw(boolean willNotDraw) 这个函数存在于 View 类中,它主要用在 View 派生子类的初始化中,如果参数 willNotDraw 取 true,则表示当前控件没有绘制内容,当屏幕重绘的时候,这个控件不需要绘制,所以在重绘的时候也就不会调用这个类的 onDraw() 函数。相反,如果参数 willNotDraw 取 false,则表示当前控件在每次重绘时,都需要绘制该控件。可见,setWillNotDraw 其实是一种优化策略,它让控件显示地告诉系统,在重绘屏幕时,哪个控件需要重绘,哪个控件不需要重绘,这样就可以大大提高重绘效率。
一般而言,想 LinearLayout、RelativeLayout 等布局控件,它们的主要功能是布局其中的控件,它们本身是没有东西需要绘制的,所以它们在构造的时候都会显示设置 setWillNotDraw(true)
总结:
- 原本能够通过派生自 View 实现的控件,依然可以通过 SurfaceView 来实现,因为 SurfaceView 派生自 View。
- 当 SurfaceView 需要使用 View 的 onDraw() 函数来重绘控件时,需要在初始化的时候调用 setWillNotDraw(false),否则 onDraw() 函数不会被调用。
- View 中的所有方法都是在主线程中执行的,所以并不建议使用 SurfaceView 重写 View 的 onDraw() 函数来实现自定义控件,而要使用 SurfaceView 特有的双缓冲机制绘图。
2. 使用缓冲 Canvas 绘图
通过以下方式来获取 SurfaceView 自带的画布。
前面说过线程同步问题,所以需要给获取的缓冲画布进行加锁,防止被其他线程更改;当绘图操作完成以后,将缓冲画布释放,并将所画内容更新到主线程的画布上,显示在屏幕上。使用缓冲画布来改造上面的示例代码。
注意,onTouchEvent() 函数是在主线程执行的,所以我们需要开启子线程更新画布。效果图如下。
3. 监听 Surface 生命周期
与 SurfaceView 相关的有三个概念:Surface、SurfaceView、SurfaceHolder。这三个概念是典型的 MVC 模式 (Model-View-Controller)。Surface 是 Model,保存着缓冲画布和绘图内容相关的各种信息;SurfaceView 是 View,负责将 Surface 中存储的数据展示在 View 上;SurfaceHolder 是 Controller,使用它才能操作 Surface 中的数据。
生命周期监听函数:
示例:动态背景效果
|
|
10.3.3 SurfaceView 双缓冲技术
1. 概述
双缓冲技术需要两个图形缓冲区:前端缓冲区和后端缓冲区。前端缓冲区对应当前屏幕正在显示的内容,而后端缓冲区是接下来要渲染的图形缓冲区。通过 surfaceHolder.lockCanvas() 函数获得的缓冲区是后端缓冲区。当绘图完成以后,调用 surfaceHolder.unlockCanvasAndPost(mCanvas) 函数将后端缓冲区与前端缓冲区交换,后端缓冲区变成前端缓冲区,将内容显示在屏幕上;而原来的前端缓冲区则变成后端缓冲区,等待下一次的 surfaceHolder.lockCanvas() 函数调用返回给用户使用,如此反复。
正是由于两块画布交替用来绘图,在绘图完成以后相互交换位置,而且在绘图完成以后直接更新到屏幕上,所以才使得绘图效率大大提高。而这样做却造成了一个问题:两块画布上的内容肯定会存在不一致的情况,尤其是在多线程的情况下。比如,我们利用一个线程操作 A、B 两款画布,目前 A 画布是屏幕画布,所以,当线程要绘图时,获得的缓冲画布是 B。在更新以后,B 画布更新到屏幕上,A 画布与 B 画布交换位置。而这时,如果线程再次申请画布,则将获取到 A 画布。如果 A 画布与 B 画布上的内容不一样,那么,在 A 画布上继续作画肯定会与预想的不一样。
示例:每获取一次画布写一个数字,循环 10 次。
效果如下图所示:
按照我们的逻辑,如果有两块缓冲画布,那么结果应该是 1 3 5 7 9。因为最后一个更新的数字必然是 9,而往前推,每次间隔使用画布,跟 9 在同一块画布上的必然是 1 3 5 7,其他数字都在另一块画布上。但结果为什么是 0 3 6 9 呢?这是因为这里有三块缓冲画布。
如果我们在绘图时使用单独的线程,而且每次绘图完成以后,让线程休眠一段时间,就可以明显地看到每次所绘制的数字了。
效果如下图所示。
从效果图中可以看出每次获取到的画布上所绘制的内容,很明显,0、1、2 这三个数字是分别在三块空白的画布上绘制的,之后的每个数字都是依次在这三块画布上绘制的。
有关 Surface 中缓冲画布的数量,Google 给出的解释 是:Surface 中缓冲画布的数量是根据需求动态分配的。如果用户获取画布的频率较慢,那么将会分配两块缓冲画布;否则,将分配 3 的倍数缓冲画布,具体分配多少块,视情况而定。
2. 双缓冲技术局部更新原理
SurfaceView 支持局部更新,可以通过 Canvas lockCanvas(Rect dirty) 函数指定获取画布的区域和大小。画布以外的地方会将现在屏幕上的内容复制过来,以保持与屏幕一致;而画布以内的区域则保持原画布内容。
- lockCanvas():用于获取整屏画布,屏幕内容不会被更新到画布上,画布保持原画布内容。
- lockCanvas(Rect dirty):用于获取指定区域的画布,画布以外的区域会保持与屏幕内容一致,画布以内的区域依然保持原画布内容。
示例:
|
|
分析过程略,得出以下几个结论:
- 缓冲画布的存取遵循 LRU(先进先出)策略。
- 画布以内的区域仍在原缓冲画布上叠加作画,画布以外的区域是从屏幕上直接复制过来的。
- 为了防止画布以内的缓冲画布本身的图像与所画内容产生冲突,在对画布以内的区域作画时,建议先清空画布。
3. 局部更新为何要先清屏
因为这里有三块缓冲画布,有一块画布初始化地被显示在屏幕上,已经被默认填充为黑色,而另外两块画布都还没有被画过。虽然我们指定了获取画布的区域范围,但是系统认为,整块画布都是脏区域,都应该被画上,所以会返回屏幕大小的画布。只有将每块画布都画过以后,才会按照我们指定的区域来返回画布大小。
4. 双缓冲技术解决方案
方案一:保存所有要绘制的内容,全屏重绘
方案二:在内容不交叉时,可以采用增量绘制
|
|
局部更新清屏代码,在每次开始运行程序时,在获取第二缓冲画布时,依然是全屏画布。但是同样的代码,从任务列表恢复程序时,又运行正常。百思不得其解!!!